Imports System.ComponentModel
Imports System.Globalization.CultureInfo
Imports System.Windows.Forms

''' <summary>
''' This is a numeric up/down control, like the standard one in .NET. But it supports the use of several units of measure
''' and built in conversion functionality. It also supports changing the value by dragging the mouse up/down from within the
''' control. It is configurable via the Visual Studio Designer.
''' </summary>
''' <remarks>TODO: Thousand Seperator; Dragging sensitivity</remarks>
Public Class NumericTextBox
   Inherits DomainUpDown

#Region "Public Events"

   ''' <summary>
   ''' Occurs when the value of a the control is changed, either by the user or programatically.
   ''' </summary>
   ''' <param name="sender"></param>
   ''' <param name="e"></param>
   ''' <remarks></remarks>
   Public Event ValueChanged(ByVal sender As Object, ByVal e As EventArgs)

#End Region

#Region "Private Fields"

   Private mValue As Nullable(Of Decimal)

   Private mDecimalPlaces As Integer

   Private mIncrement As Decimal

   Private mMaximum As Nullable(Of Decimal)

   Private mMinimum As Nullable(Of Decimal)

   Private mAllowEmpty As Boolean

   Private mBaseUnit As UnitConfiguration

   Private mUnitConfigurations As New UnitConfigurationCollection

   Private mCurrentUnit As UnitConfiguration

   Private mForceBaseUnit As Boolean

   ' Several values for the mouse drag functionality

   Private mMouseDown As Boolean = False

   Private mLastMousePosition As Point

   Private mDragValue As Boolean

   Private mWrapMouseWhenDragging As Boolean

   Private mMouseDraggingValue As Boolean

   Private mOriginalSelectionStart As Integer

   Private mOriginalSelectionLength As Integer

#End Region

#Region "Public Properties"

   ''' <summary>
   ''' The current value of the control.
   ''' </summary>
   <Category("Data"), _
    Description("The actual numerical value of the number in the numeric textbox.")> _
   Public Property Value() As Nullable(Of Decimal)
      Get
         Return mValue
      End Get
      Set(ByVal value As Nullable(Of Decimal))
         Me.SetNewValue(value)
      End Set
   End Property

   ''' <summary>
   ''' Indicates the steps with which to increment or decrement
   ''' </summary>
   <Category("Data"), _
    Description("Indicates the amount to increment or decrement with each button click.")> _
   Public Property Increment() As Decimal
      Get
         Return Me.mIncrement
      End Get
      Set(ByVal value As Decimal)
         Me.mIncrement = value
      End Set
   End Property

   ''' <summary>
   ''' Indicates the maximum value for the control. Leave empty to have no upper limit.
   ''' </summary>
   <Category("Data"), _
    Description("Indicates the maximum value for the control. Leave empty to have no upper limit.")> _
   Public Property Maximum() As Nullable(Of Decimal)
      Get
         Return Me.mMaximum
      End Get
      Set(ByVal value As Nullable(Of Decimal))
         Me.mMaximum = value
         If Me.CheckMaximumExceeded(Me.mValue) Then
            ' If we already exceed the maximum, we just set our value to the maximum
            Me.Value = Me.mMaximum
         End If
      End Set
   End Property

   ''' <summary>
   ''' Indicates the minimum value for the control. Leave empty to have no lower limit.
   ''' </summary>
   <Category("Data"), _
    Description("Indicates the minimum value for the control. Leave empty to have no lower limit.")> _
   Public Property Minimum() As Nullable(Of Decimal)
      Get
         Return Me.mMinimum
      End Get
      Set(ByVal value As Nullable(Of Decimal))
         Me.mMinimum = value
         If Me.CheckMinimumExceeded(Me.mValue) Then
            Me.Value = Me.mMinimum
         End If
      End Set
   End Property

   ''' <summary>
   ''' Indicates if the value can be empty or not.
   ''' </summary>
   <Category("Data"), _
    Description("Indicates whether the value can be empty.")> _
   Public Property AllowEmpty() As Boolean
      Get
         Return Me.mAllowEmpty
      End Get
      Set(ByVal value As Boolean)
         Me.mAllowEmpty = value
      End Set
   End Property

   ''' <summary>
   ''' Indicates whether the control has a value or not.
   ''' </summary>
   ''' <value></value>
   ''' <returns></returns>
   ''' <remarks></remarks>
   <Category("Data"), _
    Browsable(False), _
    Description("Indicates whether or not the control has a value.")> _
   Public ReadOnly Property IsEmpty() As Boolean
      Get
         Return Not Me.mValue.HasValue
      End Get
   End Property

   ''' <summary>
   ''' Indicates the number of decimal places to display (this is overriden by any unit configuration).
   ''' </summary>
   <Category("Data"), _
    Description("Indicates the number of decimal places to display (this is overriden by any unit configuration).")> _
   Public Property DecimalPlaces() As Integer
      Get
         Return mDecimalPlaces
      End Get
      Set(ByVal value As Integer)
         mDecimalPlaces = value

         Me.UpdateEditText()
      End Set
   End Property

   ''' <summary>
   ''' Indicates the standard unit that will be used if the user specifies no unit at all when entering text.
   ''' </summary>
   <Category("Unit Configuration"), _
    Description("Indicates the standard unit that will be used if the user specifies no unit at all when entering text.")> _
   Public Property BaseUnit() As UnitConfiguration
      Get
         Return Me.mBaseUnit
      End Get
      Set(ByVal value As UnitConfiguration)
         Me.mBaseUnit = value
      End Set
   End Property

   ''' <summary>
   ''' The actual unit in which the current value is shown.
   ''' </summary>
   ''' <remarks></remarks>
   <Category("Unit Configuration"), _
    Browsable(False), _
    Description("The actual unit in which the current value is shown.")> _
   Private Property CurrentUnit() As UnitConfiguration
      Get
         If Me.mCurrentUnit Is Nothing Then
            Me.mCurrentUnit = Me.BaseUnit
         End If

         Return Me.mCurrentUnit
      End Get
      Set(ByVal value As UnitConfiguration)
         Me.mCurrentUnit = value
      End Set
   End Property

   ''' <summary>
   ''' Indicates that the control should always display its value in the base unit (as opposed to the unit given by the user).
   ''' </summary>
   ''' <remarks>If this is true and the user enters a value with unit B when unit A is the base unit, the control will immediatly convert the value to unit A.</remarks>
   <Category("Unit Configuration"), _
    Description("Indicates that the control should always display its value in the base unit (as opposed to the unit given by the user). If this is true and the user enters a value with unit B when unit A is the base unit, the control will immediatly convert the value to unit A.")> _
   Public Property ForceBaseUnit() As Boolean
      Get
         Return Me.mForceBaseUnit
      End Get
      Set(ByVal value As Boolean)
         Me.mForceBaseUnit = value
      End Set
   End Property

   ''' <summary>
   ''' A collection of all units that are to be supported by the control.
   '''  Browsable(True), _
   ''' Description("A collection of all units that are to be supported by the control. Leave empty if you do not wish to use units.")
   ''' </summary>
   <TypeConverter(GetType(ExpandableObjectConverter)), _
    System.ComponentModel.DesignerSerializationVisibility(DesignerSerializationVisibility.Content)> _
   Public Property UnitConfigurations() As UnitConfigurationCollection
      Get
         Return mUnitConfigurations
      End Get
      Set(ByVal value As UnitConfigurationCollection)
         mUnitConfigurations = value
      End Set
   End Property

   <Category("Behavior"), _
    Description("When true a user can drag the mouse up or down from within the control to change the value up or down respectively."), _
    DefaultValue("True")> _
   Public Property DragValue() As Boolean
      Get
         Return Me.mDragValue
      End Get
      Set(ByVal value As Boolean)
         Me.mDragValue = value
      End Set
   End Property

   <Category("Behavior"), _
    Description("If DragValue is active, this will specify whether or not the mouse cursor should wrap if it is dragged to the top or bottom of the screen."), _
    DefaultValue("True")> _
   Public Property WrapMouseWhenDragging() As Boolean
      Get
         Return Me.mWrapMouseWhenDragging
      End Get
      Set(ByVal value As Boolean)
         Me.mWrapMouseWhenDragging = value
      End Set
   End Property

#End Region

#Region "Public DomainUpDown Overrides"

   ''' <summary>
   ''' Executes a decrement of the value according to the "Increment" value.
   ''' </summary>
   Public Overrides Sub DownButton()
      If Me.IsEmpty Then Me.mValue = 0
      Me.Value = Me.Value.Value - Me.Increment
      Me.UpdateEditText()
   End Sub

   ''' <summary>
   ''' Executes an increment of the value according to the "Increment" value.
   ''' </summary>
   Public Overrides Sub UpButton()
      If Me.IsEmpty Then Me.mValue = 0
      Me.Value = Me.Value.Value + Me.Increment
      Me.UpdateEditText()
   End Sub

#End Region

#Region "Protected Methods"

   ''' <summary>
   ''' Updates the displayed text with the current value.
   ''' </summary>
   ''' <remarks></remarks>
   Protected Overrides Sub UpdateEditText()
      If Not Me.IsEmpty Then
         Me.Text = Me.GetDisplayText(Me.Value.Value)
      Else
         Me.Text = String.Empty
      End If
   End Sub

#End Region

#Region "Protected Methods"

   ''' <summary>
   ''' Will try and get a conversion factor from a given unit of measure.
   ''' </summary>
   ''' <param name="iUnit"></param>
   ''' <returns></returns>
   ''' <remarks>If no conversion factor could be found, it is assumed to be 1 (which has no conversion effects).</remarks>
   Protected Function GetConversionFactor(ByVal iUnit As UnitConfiguration) As Decimal
      If iUnit IsNot Nothing Then
         If iUnit.ConversionFactor.HasValue Then Return iUnit.ConversionFactor.Value
      End If

      Return 1
   End Function

   ''' <summary>
   ''' Generates a number format to use in Decimal.ToString() for the given unit configuration.
   ''' </summary>
   ''' <param name="iUnit"></param>
   ''' <returns></returns>
   ''' <remarks></remarks>
   Protected Function GetFormat(ByVal iUnit As UnitConfiguration) As String
      Dim numberOfDecimals As Integer
      Dim result As String

      If iUnit Is Nothing Then
         numberOfDecimals = Me.DecimalPlaces
      Else
         numberOfDecimals = iUnit.DecimalPlaces
      End If

      ' Create the required number format
      result = "0"
      If numberOfDecimals > 0 Then
         result += CurrentUICulture.NumberFormat.NumberDecimalSeparator
      End If
      For i As Integer = 1 To numberOfDecimals
         result += "0"
      Next

      Return result
   End Function

   ''' <summary>
   ''' Gets the actual text that will be shown in the control.
   ''' </summary>
   ''' <param name="iValue"></param>
   ''' <returns></returns>
   ''' <remarks></remarks>
   Protected Function GetDisplayText(ByVal iValue As Decimal) As String
      Dim unit As UnitConfiguration
      Dim format As String
      Dim conversionFactor As Decimal

      unit = Me.CurrentUnit

      If unit Is Nothing AndAlso Me.ForceBaseUnit AndAlso Me.BaseUnit IsNot Nothing Then
         unit = BaseUnit
      End If

      conversionFactor = Me.GetConversionFactor(unit)
      If conversionFactor = 0 Then iValue = 0 Else iValue = iValue / conversionFactor

      format = Me.GetFormat(unit)

      If unit IsNot Nothing Then
         Select Case unit.Location
            Case WindowsForms.Controls.UnitLocation.After
               format = String.Format("{0} {1}", format, unit.Unit)
            Case WindowsForms.Controls.UnitLocation.Before
               format = String.Format("{0} {1}", unit.Unit, format)
         End Select
      End If

      Return iValue.ToString(format)
   End Function

   ''' <summary>
   ''' Reads in text that is given by the user, converts it to a value if possible and validates it.
   ''' </summary>
   ''' <param name="iText"></param>
   ''' <param name="iValue"></param>
   ''' <returns>False if the given text was not a valid format.</returns>
   ''' <remarks></remarks>
   Protected Function ParseDisplayText(ByVal iText As String, _
                                       ByRef iValue As Nullable(Of Decimal)) As Boolean
      Dim value As Decimal
      Dim valid As Boolean
      Dim conversionFactor As Decimal
      Dim unit As UnitConfiguration

      If String.IsNullOrEmpty(iText) Then
         If Me.AllowEmpty Then
            iValue = Nothing
            valid = True
         Else
            valid = False
         End If
      Else
         unit = Me.StripUnitFromDisplayText(iText)

         valid = Decimal.TryParse(iText, value)
         If valid Then
            conversionFactor = Me.GetConversionFactor(unit)
            ' Calculate from given unit to the default unit...
            iValue = value * conversionFactor
            If Not Me.CheckMinimumExceeded(iValue) AndAlso Not Me.CheckMaximumExceeded(iValue) AndAlso Not Me.ForceBaseUnit Then
               Me.mCurrentUnit = unit ' Set our currently used unit now?
            End If
         End If
      End If

      Return valid
   End Function

   ''' <summary>
   ''' Will try and find out for which unit this value is given (by stripping of the unit and comparing to those in the unit list).
   ''' </summary>
   ''' <param name="iText"></param>
   ''' <returns></returns>
   ''' <remarks></remarks>
   Protected Function StripUnitFromDisplayText(ByRef iText As String) As UnitConfiguration
      Dim testValue As Decimal

      If Decimal.TryParse(iText, testValue) Then
         Return Me.BaseUnit ' We have no unit in the text, so we return the base unit
      End If

      For Each unitConfig As UnitConfiguration In Me.UnitConfigurations
         Select Case unitConfig.Location
            Case UnitLocation.After
               If iText.IndexOf(unitConfig.Unit) = iText.Length - unitConfig.Unit.Length Then
                  iText = iText.Substring(0, iText.IndexOf(unitConfig.Unit))
                  Return unitConfig
               End If
            Case UnitLocation.Before
               If iText.IndexOf(unitConfig.Unit) = 0 Then
                  iText = iText.Substring(unitConfig.Unit.Length, iText.Length - unitConfig.Unit.Length)
                  Return unitConfig
               End If
         End Select
      Next

      Return Nothing
   End Function

   ''' <summary>
   ''' Will check if the given value exceeds the maximum allowed value.
   ''' </summary>
   ''' <param name="iValue"></param>
   ''' <returns></returns>
   ''' <remarks></remarks>
   Protected Function CheckMaximumExceeded(ByVal iValue As Nullable(Of Decimal)) As Boolean
      If Not iValue.HasValue Then Return False
      Return Me.mMaximum.HasValue AndAlso iValue.Value > Me.mMaximum.Value
   End Function

   ''' <summary>
   ''' Will check if the given value is below the minimum allowed value.
   ''' </summary>
   ''' <param name="iValue"></param>
   ''' <returns></returns>
   ''' <remarks></remarks>
   Protected Function CheckMinimumExceeded(ByVal iValue As Nullable(Of Decimal)) As Boolean
      If Not iValue.HasValue Then Return False
      Return Me.mMinimum.HasValue AndAlso iValue.Value < Me.mMinimum.Value
   End Function

   ''' <summary>
   ''' Checks if the given new value is valid for the current configuration and raises events if needed
   ''' </summary>
   ''' <param name="iNewValue">The new value to give to the TextBox</param>
   ''' <remarks></remarks>
   Protected Sub SetNewValue(ByVal iNewValue As Nullable(Of Decimal))
      Dim setNewValue As Boolean ' Indicates whether it's ok to change the value
      Dim valueChanged As Boolean ' Indicates whether or not the value is changed

      setNewValue = False

      ' First check if the value is allowed within the maximum and minimum boundraries
      If iNewValue.HasValue Then
         If Not Me.CheckMinimumExceeded(iNewValue) AndAlso _
            Not Me.CheckMaximumExceeded(iNewValue) Then
            setNewValue = True
         End If
      Else
         setNewValue = True
      End If

      ' Check if the given value is a new value or the same as the existing one
      If Me.mValue.HasValue <> iNewValue.HasValue Then
         valueChanged = True
      Else
         If Me.mValue.HasValue Then
            valueChanged = Me.mValue.Value <> iNewValue.Value
         End If
      End If

      If valueChanged AndAlso setNewValue Then
         Me.mValue = iNewValue
         RaiseEvent ValueChanged(Me, New EventArgs)
      End If

      ' We update our display text even if the value hasn't changed
      Me.UpdateEditText()
   End Sub

   ''' <summary>
   ''' Executed when the value dragging should commence... i.e. when the control should listen and identify a drag action. 
   ''' </summary>
   ''' <param name="iMouseLocation"></param>
   ''' <remarks></remarks>
   Protected Sub StartValueDrag(ByVal iMouseLocation As Point)
      If Me.Controls(1).ClientRectangle.Contains(iMouseLocation) Then
         Me.mLastMousePosition = iMouseLocation
         Me.mMouseDown = True
         Me.mOriginalSelectionStart = CType(Me.Controls(1), TextBox).SelectionStart
         Me.mOriginalSelectionLength = CType(Me.Controls(1), TextBox).SelectionLength
         AddHandler Me.Controls(1).MouseMove, AddressOf TextBoxMouseMoveHandler
      End If
   End Sub

   ''' <summary>
   ''' Executed when the control should release the dragging action (if needed)
   ''' </summary>
   ''' <remarks></remarks>
   Protected Sub EndValueDrag()
      If Me.mMouseDown Then
         If Me.mMouseDraggingValue Then
            Me.Select(Me.mOriginalSelectionStart, Me.mOriginalSelectionLength)
         End If
         Me.mMouseDraggingValue = False
         Me.mMouseDown = False
         Me.Capture = False
      End If
   End Sub

   ''' <summary>
   ''' Executed when the user is actively in "value dragging" mode, i.e. the value is changing every time the mouse moves up ten pixels.
   ''' </summary>
   ''' <param name="iMouseLocation"></param>
   ''' <remarks></remarks>
   Protected Sub HandleMouseMoveDraggingValue(ByVal iMouseLocation As Point)
      Dim screenheight As Integer
      Dim screenLocation As Point

      If Me.mMouseDown AndAlso Me.Value.HasValue Then
         If Me.WrapMouseWhenDragging Then
            screenheight = Screen.PrimaryScreen.Bounds.Height
            screenLocation = Me.PointToScreen(iMouseLocation)
            If screenLocation.Y <= 0 Then
               Cursor.Position = New Point(Cursor.Position.X, screenheight - 2)
            End If
            If screenLocation.Y >= screenheight - 1 Then
               Cursor.Position = New Point(Cursor.Position.X, 1)
            End If
         End If
         If iMouseLocation.Y < (Me.mLastMousePosition.Y - 10) Then
            Me.Select(0, Me.Text.Length)
            Me.mMouseDraggingValue = True
            Me.UpButton()
            Me.mLastMousePosition = iMouseLocation
            Me.Select(0, Me.Text.Length)
         ElseIf iMouseLocation.Y > (Me.mLastMousePosition.Y + 10) Then
            Me.Select(0, Me.Text.Length)
            Me.mMouseDraggingValue = True
            Me.DownButton()
            Me.mLastMousePosition = iMouseLocation
            Me.Select(0, Me.Text.Length)
         End If
      End If
   End Sub

   ''' <summary>
   ''' This will happen when the user is dragging the mouse from within the textbox of the numeric up down control but is not yet actively
   ''' changing the value. This can happen for example when he is dragging sideways. The user has to drag the mouse up for 30 pixels to commence
   ''' the actual value dragging, this method will then remove the standard even handler and set the Capture to Me instead of to the child textbox control.
   ''' </summary>
   ''' <param name="iMouseLocation"></param>
   ''' <remarks></remarks>
   Protected Sub HandleMouseMoveStandard(ByVal iMouseLocation As Point)
      If Me.mMouseDown AndAlso Me.Value.HasValue Then
         If iMouseLocation.Y < (Me.mLastMousePosition.Y - 30) Then
            RemoveHandler Me.Controls(1).MouseMove, AddressOf TextBoxMouseMoveHandler
            Me.Capture = True
            Me.Select(0, Me.Text.Length)
            Me.mMouseDraggingValue = True
            Me.UpButton()
            Me.mLastMousePosition = iMouseLocation
            Me.Select(0, Me.Text.Length)
         ElseIf iMouseLocation.Y > (Me.mLastMousePosition.Y + 30) Then
            RemoveHandler Me.Controls(1).MouseMove, AddressOf TextBoxMouseMoveHandler
            Me.Capture = True
            Me.Select(0, Me.Text.Length)
            Me.mMouseDraggingValue = True
            Me.DownButton()
            Me.mLastMousePosition = iMouseLocation
            Me.Select(0, Me.Text.Length)
         Else
            Me.mOriginalSelectionStart = CType(Me.Controls(1), TextBox).SelectionStart
            Me.mOriginalSelectionLength = CType(Me.Controls(1), TextBox).SelectionLength
         End If
      Else
         Me.mOriginalSelectionStart = CType(Me.Controls(1), TextBox).SelectionStart
         Me.mOriginalSelectionLength = CType(Me.Controls(1), TextBox).SelectionLength
      End If
   End Sub

   ''' <summary>
   ''' Validates the value entered in the textbox, if the entered data is valid, the value is set accordingly.
   ''' </summary>
   ''' <param name="e"></param>
   ''' <remarks></remarks>
   Protected Overrides Sub OnValidating(ByVal e As System.ComponentModel.CancelEventArgs)
      Dim value As Nullable(Of Decimal)

      MyBase.OnValidating(e)

      If Me.ParseDisplayText(Me.Text, value) Then
         Me.Value = value
         e.Cancel = False
      Else
         e.Cancel = True
      End If
   End Sub

   ''' <summary>
   ''' Code executed when the layout is complete.
   ''' </summary>
   ''' <param name="e"></param>
   ''' <remarks></remarks>
   Protected Overrides Sub OnLayout(ByVal e As System.Windows.Forms.LayoutEventArgs)

      MyBase.OnLayout(e)

      If Not Me.IsEmpty Then
         Me.Text = Me.GetDisplayText(Me.Value.Value)
      End If
   End Sub

   ''' <summary>
   ''' Code execute when the user presses down the mouse inside of the control.
   ''' </summary>
   ''' <param name="e"></param>
   ''' <remarks></remarks>
   Protected Overrides Sub OnMouseDown(ByVal e As System.Windows.Forms.MouseEventArgs)
      MyBase.OnMouseDown(e)

      If Me.DragValue Then
         Me.StartValueDrag(e.Location)
      End If
   End Sub

   ''' <summary>
   ''' Code executed when the user moves the mouse around and this control captures that movement, i.e. when Me has Capture set to True
   ''' </summary>
   ''' <param name="e"></param>
   ''' <remarks></remarks>
   Protected Overrides Sub OnMouseMove(ByVal e As System.Windows.Forms.MouseEventArgs)
      MyBase.OnMouseMove(e)

      If Me.DragValue Then
         Me.HandleMouseMoveDraggingValue(e.Location)
      End If
   End Sub

   ''' <summary>
   ''' Executed when the user is moving the mouse within the internal TextBox and the TextBox still has Capture focus.
   ''' </summary>
   ''' <param name="sender"></param>
   ''' <param name="e"></param>
   ''' <remarks></remarks>
   Protected Sub TextBoxMouseMoveHandler(ByVal sender As Object, ByVal e As System.Windows.Forms.MouseEventArgs)
      MyBase.OnMouseMove(e)
      If Me.DragValue Then
         Me.HandleMouseMoveStandard(e.Location)
      End If
   End Sub

   ''' <summary>
   ''' Executed when the mouse button is released.
   ''' </summary>
   ''' <param name="mevent"></param>
   ''' <remarks></remarks>
   Protected Overrides Sub OnMouseUp(ByVal mevent As System.Windows.Forms.MouseEventArgs)
      MyBase.OnMouseUp(mevent)

      If Me.DragValue Then
         Me.EndValueDrag()
      End If
   End Sub

#End Region

End Class